-
Notifications
You must be signed in to change notification settings - Fork 310
notif ios: Handle opening of conversation on tap; take 2 #1379
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
463b9ee
to
8478226
Compare
0142dbd
to
89df63b
Compare
89df63b
to
4f3224a
Compare
80a34eb
to
9c07740
Compare
Ah this has gathered a conflict in lib/widgets/app.dart; could you resolve it please? (I see you did a few days ago, but looks like it's happened again; thanks. 🙂) |
3b50218
to
2178fe6
Compare
(Rebased to main, Thanks!) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! There's a lot here, and I haven't gotten around to it all today. But here are some comments from an initial review.
lib/widgets/app.dart
Outdated
GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context); | ||
return child!; | ||
}, | ||
return DeferrredBuilderWidget( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: check spelling (here and in commit message)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(also spelling of _ZulipAppState.initState
in the commit message)
lib/widgets/store.dart
Outdated
/// Provides access to the app's data. | ||
/// | ||
/// There should be one of this widget, near the root of the tree. | ||
/// | ||
/// See also: | ||
/// * [GlobalStoreWidget.of], to get access to the data. | ||
/// * [PerAccountStoreWidget], for the user's data associated with a | ||
/// particular Zulip account. | ||
class GlobalStoreWidget extends StatefulWidget { | ||
// This is separate from [GlobalStoreWidget] only because we need | ||
// a [StatefulWidget] to get hold of the store, and an [InheritedWidget] to | ||
// provide it to descendants, and one widget can't be both of those. | ||
class GlobalStoreWidget extends InheritedNotifier<GlobalStore> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you mean to move this implementation comment here and delete the dartdoc? The implementation comment doesn't make sense in this context—saying GlobalStoreWidget
is "separate from" GlobalStoreWidget
.
test/widgets/content_test.dart
Outdated
child: PerAccountStoreWidget(accountId: eg.selfAccount.id, | ||
child: RealmContentNetworkImage(src)))); | ||
await tester.pumpWidget(DeferrredBuilderWidget( | ||
future: ZulipBinding.instance.getGlobalStoreUniquely(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We normally do this as testBinding.getGlobalStoreUniquely
, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, for these tests that aren't specifically about GlobalStoreWidget
, it would be simpler to use TestZulipApp
instead, I think.
test/widgets/store_test.dart
Outdated
store: store, | ||
child: PerAccountStoreWidget( | ||
accountId: accountId, | ||
child: MyWidgetWithMixin(key: widgetWithMixinKey))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can use TestZulipApp
for this test?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Used TestZulipApp
in tests where possible, but kept this unchanged because I was getting the same extraneous dep changes mentioned in the above TODO.
lib/notifications/open.dart
Outdated
case TargetPlatform.android: | ||
case TargetPlatform.fuchsia: | ||
case TargetPlatform.linux: | ||
case TargetPlatform.macOS: | ||
case TargetPlatform.windows: | ||
// Do nothing; we don't offer notifications on these platforms. | ||
break; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment is wrong about Android; we do offer notifications on Android.
A lot of the code added in this commit is implemented just for iOS, but named/documented as though it's cross-platform, because it doesn't say it's just for iOS.
I don't know if we plan to align the implementation with the names/docs or vice versa. The answer might be in the later commits (I haven't read them yet), but it would be helpful to comment on this in the commit message, I think.
lib/widgets/app.dart
Outdated
List<Route<dynamic>> _handleGenerateInitialRoutesIos(_) { | ||
// The `_ZulipAppState.context` lacks the required ancestors. Instead | ||
// we use the Navigator which should be available when this callback is | ||
// called and it's context should have the required ancestors. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: "its"
test/notifications/open_test.dart
Outdated
await tester.pump(); | ||
takeStartingRoutes(); | ||
matchesNavigation(check(pushedRoutes).single, account, message); | ||
debugDefaultTargetPlatformOverride = null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, awkward to have this teardown line so far away from its corresponding setup line.
How about instead passing variant: const TargetPlatformVariant({TargetPlatform.iOS}))
to testWidgets
?
final route = _routeForNotification(context, payload); | ||
if (route == null) return; // TODO(log) | ||
|
||
// TODO(nav): Better interact with existing nav stack on notif open |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we should have an iOS counterpart to
for this? Or make that a both-platforms issue? That issue says:
(The iOS counterpart is covered by #1147, for navigating at all when a notification is tapped.)
but it seems reasonable to postpone this part of it; we'd just want to keep track of it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After this PR is merged the potential fix for that issue would work on both platforms, because this PR consolidates the notification routing implementation on both iOS and Android.
pigeon/notifications.dart
Outdated
@EventChannelApi() | ||
abstract class NotificationHostEvents { | ||
/// An event stream that emits a notification payload when | ||
/// app encounters a notification tap, while the app is runnning. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: "the app encounters a notification tap, while the app is running."
test/notifications/open_test.dart
Outdated
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); | ||
await prepare(tester); | ||
await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage()); | ||
debugDefaultTargetPlatformOverride = null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(same comment about maybe using variant: const TargetPlatformVariant({TargetPlatform.iOS})
)
2178fe6
to
616defe
Compare
Thanks for the review @chrisbobbe! Pushed a new revision, PTAL. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Here's a more thorough review of the first four commits:
d1f648c app: Use DeferredBuilderWidget while loading GlobalStore
a5a0fff pigeon [nfc]: Rename pigeon file to notification
-> android_notifications
ab3ff84 notif ios: Navigate when app launched from notification
4b2ade0 notif ios: Navigate when app running but in background
That leaves the last two commits:
28ea77f notif android: Migrate to cross-platform Pigeon API for navigation
616defe docs: Document testing push notifications on iOS Simulator
Actually for your next revision, could you send a new PR with everything except the "Migrate to cross-platform" commit? That one's pretty large, so makes sense to review separately.
ios/Runner/Notifications.g.swift
Outdated
@@ -0,0 +1,167 @@ | |||
// Autogenerated from Pigeon (v24.2.1), do not edit directly. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This needs an update for the Pigeon 25 upgrade e2aac35.
lib/widgets/app.dart
Outdated
/// The widget to build when [future] completes, with it's result | ||
/// passed as `result`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: "its"
@@ -381,7 +381,7 @@ data class StoredNotificationSound ( | |||
) | |||
} | |||
} | |||
private open class NotificationsPigeonCodec : StandardMessageCodec() { | |||
private open class AndroidNotificationsPigeonCodec : StandardMessageCodec() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Commit-message nit:
pigeon [nfc]: Rename pigeon file to `notification` -> `android_notifications`
I think the "to" should be deleted? Or moved to replace the "->"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pigeon [nfc]: Rename pigeon file `notifications.dart` to `android_notifications.dart`
commit-message nit: limit summary line length to 76 (this is 85).
pigeon/notifications.dart
Outdated
/// On iOS, this checks and returns value for the `remoteNotification` key | ||
/// in the `launchOptions` map. The value could be either the raw APNs data | ||
/// dictionary, if the launch of the app was triggered by a notification tap, | ||
/// otherwise it will be null. | ||
/// | ||
/// See: https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can be a bit more concise I think:
/// Returns `launchOptions.remoteNotification`,
/// which is the raw APNs data dictionary
/// if the app launch was opened by a notification tap,
/// else null. See Apple doc:
/// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification
And this is only used on iOS at this commit, right; the "On iOS" can be added in the later commit where it also starts being used on Android.
test/model/binding.dart
Outdated
FakeNotificationPigeonApi? _notificationPigeonApi; | ||
|
||
@override | ||
FakeNotificationPigeonApi get notificationPigeonApi { | ||
return (_notificationPigeonApi ??= FakeNotificationPigeonApi()); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, can use fewer lines:
@override
FakeNotificationPigeonApi get notificationPigeonApi =>
(_notificationPigeonApi ??= FakeNotificationPigeonApi());
ios/Runner/AppDelegate.swift
Outdated
func onNotificationTapEvent(data: NotificationPayloadForOpen) { | ||
if let eventSink = eventSink { | ||
eventSink.success(data) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of the one class NotificationPayloadForOpen
, could we have two separate classes NotificationDataFromLaunch
and NotificationTapEvent
? Perhaps with a Payload
typedef helper:
typedef Payload = Map<Object?, Object?>;
class NotificationDataFromLaunch {
const NotificationDataFromLaunch({required this.payload});
/// The raw payload that is attached to the notification,
/// holding the information required to carry out the navigation.
final Payload payload;
}
class NotificationTapEvent {
const NotificationTapEvent({required this.payload});
/// The raw payload that is attached to the notification,
/// holding the information required to carry out the navigation.
final Payload payload;
}
I think the current naming makes the event-channel code harder to read than it needs to be. When reading the Pigeon example code, I see "event" used pretty consistently in the names of things. Here, we have both "data" (onNotificationTapEvent
's param) and "payload", and "payload" is used ambiguously for a payload (NotificationPayloadForOpen.payload
) and something that contains a payload (NotificationPayloadForOpen
itself).
That would mean, for this method:
func onNotificationTapEvent(event: NotificationTapEvent) {
if let eventSink = eventSink {
eventSink.success(event)
}
}
pigeon/notifications.dart
Outdated
@EventChannelApi() | ||
abstract class NotificationHostEvents { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would NotificationEventChannelApi
be a better name for this, to make it clearer what kind of thing it is?
lib/model/binding.dart
Outdated
// in global scope of the generated file. This is a helper class to | ||
// namespace the notification related Pigeon API under a single class. | ||
class NotificationPigeonApi { | ||
final _notifInteractionHost = notif_pigeon.NotificationHostApi(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is _notifInteractionHost
the best name for this? How about _hostApi
? We might add more to NotificationHostApi
that's not about interacting with notifications (such as a method to query the current notification permissions). Also, NotificationHostApi
isn't the only code that's about interacting with notifications; notif_pigeon.notificationTapEvents
is too.
lib/notifications/open.dart
Outdated
/// Navigates to the [MessageListPage] of the specific conversation | ||
/// for the provided payload that was attached while creating the | ||
/// notification. | ||
Future<void> _navigateForNotification(NotificationPayloadForOpen payload) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another reason the NotificationPayloadForOpen
rename would be helpful: currently, it's pretty unclear that this private method is only about notifications that come while the app is open. It'll be helpful for debugging if it's easier to see what code is about the launch notification vs. not.
test/notifications/open_test.dart
Outdated
|
||
testWidgets('(iOS) stream message', (tester) async { | ||
addTearDown(testBinding.reset); | ||
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This add-self-account line looks boring; can we put it in prepare
?
173f11f
to
9813a4d
Compare
920ee7a
to
1c03950
Compare
Thanks for the review @gnprice! Pushed a new revision, PTAL. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for splitting up that commit! That was very helpful for my reading those changes. Here's a full review of the resulting two commits:
80a89e3 notif [nfc]: Introduce NotificationNavigationServer
b8cfff7 notif ios: Navigate when app launched from notification
except the tests.
I'll take a look at those tests next. Then the remaining to-do list for review will be the final commit:
1c03950 notif ios: Navigate when app running but in background
lib/notifications/navigate.dart
Outdated
/// The context argument is used to look up the [Navigator], which is used | ||
/// to show an error dialog if there is a failure. | ||
AccountRoute<void>? routeForNotificationFromLaunch({required BuildContext context}) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
similar to #1379 (comment)
lib/widgets/app.dart
Outdated
} else { | ||
// The account didn't match any existing accounts, | ||
// fall through to show the default route below. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This comment doesn't seem accurate. This condition will indeed occur if the data for the initial route specified an account which didn't match any existing accounts… but it will also occur if the data for the initial route didn't specify any account at all.
In particular I believe this condition will occur every time the app is launched other than by opening a notification.
lib/widgets/app.dart
Outdated
onGenerateRoute: (_) => null, | ||
|
||
onGenerateInitialRoutes: _handleGenerateInitialRoutes); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: stray diff hunk
lib/notifications/navigate.dart
Outdated
try { | ||
return NotificationNavigationData.parseAndroidNotificationUrl(url); | ||
} on FormatException catch (e, st) { | ||
assert(debugLog('$e\n$st')); | ||
final zulipLocalizations = ZulipLocalizations.of(context); | ||
showErrorDialog(context: context, | ||
title: zulipLocalizations.errorNotificationOpenTitle); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
notif [nfc]: Introduce NotificationNavigationServer
And move the notification navigation data parsing utilities to
the new class.
This change doesn't look like it's NFC (see definition). If the URL doesn't parse, the old code would throw (because it called parseAndroidNotificationUrl
directly); this version will instead pop up an error dialog.
The dialog seems like a fine change. That change should go in a different commit, though (either before or after). The great value of the NFC concept is that knowing a given commit is supposed to be NFC can help make it a lot simpler to read: all the logic in the red -
parts of the diff will match up, not necessarily textually but semantically, with logic in the green +
parts of the same diff. The reader can therefore mentally cross off all the bits that match up, aiming to get to zero.
lib/widgets/app.dart
Outdated
final route = _initialRouteAndroid(context, initialRoute); | ||
final route = defaultTargetPlatform == TargetPlatform.iOS | ||
? _initialRouteIos(context) | ||
: _initialRouteAndroid(context, initialRoute); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like this diff hunk — it expresses pretty crisply the fact that before this change, we didn't respond to an initial route on iOS (only on Android), and after this change, we do.
lib/notifications/navigate.dart
Outdated
/// A [Future] that completes to signal that the initialization of | ||
/// [NotificationNavigationService] has completed or errored. | ||
/// | ||
/// Returns null if [start] wasn't called yet. | ||
Future<void>? get initializationFuture => _initializedSignal?.future; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In Future terminology, "complete" includes both success and failure. See e.g. [Future.whenComplete].
/// A [Future] that completes to signal that the initialization of | |
/// [NotificationNavigationService] has completed or errored. | |
/// | |
/// Returns null if [start] wasn't called yet. | |
Future<void>? get initializationFuture => _initializedSignal?.future; | |
/// A [Future] that completes to signal that the initialization of | |
/// [NotificationNavigationService] has completed | |
/// (with either success or failure). | |
/// | |
/// Null if [start] hasn't been called. | |
Future<void>? get initializationFuture => _initializedSignal?.future; |
(also includes nit: omit "returns" for describing getter; a getter impersonates a field, so it just "is" its value; and "wasn't" doesn't sound right to me tense-wise)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the name, how about just initialized
? It's common for things of type Future
to have names that describe the future's result, letting the type speak for the fact that it's a future.
lib/notifications/navigate.dart
Outdated
/// Service for handling notification navigation. | ||
class NotificationNavigationService { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Re NotificationNavigationData
, we'll probably want that to end up covering the more general "open" concept too: in particular we'll need the message ID from the notification, and that will naturally become another field on that class. That more general concept is reflected in the name of the existing NotificationOpenPayload
, and in the dartdoc this version has which I supplied at #1379 (comment) above.)
lib/notifications/navigate.dart
Outdated
unawaited(navigator.push(route)); | ||
} | ||
|
||
NotificationNavigationData? _tryParseIosApnsPayload( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this be static? Seems similar to tryParseAndroidNotificationUrl
just below it.
lib/notifications/navigate.dart
Outdated
assert(context.mounted); | ||
if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that | ||
|
||
final notifNavData = _tryParseIosApnsPayload(context, event.payload); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like this _navigateForNotification
method should get an assert that the platform is iOS, to clarify why this line makes sense.
lib/notifications/navigate.dart
Outdated
/// Navigates to the [MessageListPage] of the specific conversation | ||
/// for the provided payload that was attached while creating the | ||
/// notification. | ||
Future<void> _navigateForNotification(NotificationTapEvent event) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this method be static? Doesn't look like it's using any instance data.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, and here's a review on the tests in that next-to-last commit:
b8cfff7 notif ios: Navigate when app launched from notification
I think some of the changes requested here will help make those tests easier to read and understand, so for this round I haven't yet read every line.
test/model/store_test.dart
Outdated
addTearDown(NotificationService.debugReset); | ||
testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.zulip.flutter'); | ||
addTearDown(NotificationService.debugReset); | ||
addTearDown(NotificationNavigationService.debugReset); | ||
await NotificationService.instance.start(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm yeah, good thought reordering these while you're here editing them.
test/notifications/display_test.dart
Outdated
addTearDown(NotificationService.debugReset); | ||
addTearDown(NotificationNavigationService.debugReset); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'd be good if we can avoid going and adding another detail like this that tests have to know about.
How about having NotificationService.debugReset
call the new service's debugReset
? Seems appropriate for the main service to take responsibility for resetting the new one, given that the main service is also the one place responsible for starting up the new service.
|
||
Future<void> init() async { | ||
addTearDown(testBinding.reset); | ||
testBinding.firebaseMessagingInitialToken = '012abc'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this line needed? I'd think this data wouldn't be involved in any of these tests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, otherwise we hit this assert here:
zulip-flutter/test/model/binding.dart
Lines 492 to 495 in a4c564b
assert(_initialToken != null, | |
'Tests that call [NotificationService.start], or otherwise cause' | |
' a call to `ZulipBinding.instance.firebaseMessaging.getToken`,' | |
' must set `testBinding.firebaseMessagingInitialToken` first.'); |
Because ZulipBinding.firebaseMessaging.getToken
ends up being called in NotificationService.start
.
test/notifications/display_test.dart
Outdated
group('NotificationOpenPayload', () { | ||
group('NotificationNavigationData', () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the code this is testing is getting moved between files, the tests should move to remain in a corresponding file.
lib/notifications/navigate.dart
Outdated
|
||
/// Parses the iOS APNs payload and retrieves the information | ||
/// required for navigation. | ||
factory NotificationNavigationData.fromIosApnsPayload(Map<Object?, Object?> payload) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, does this function have tests? I don't see them. (I do see a variety of test cases for the logic downstream of here, which is good.)
This logic is fairly densely packed with fiddly details, like parsing logic tends to be, so it's logic that's valuable to have good tests for. And, again typically for parsing logic, it has a pretty crisp interface for both input and output, which makes it nicely amenable to writing plenty of unit tests. So let's go ahead and do that.
For a point of comparison, see the existing tests of NotificationOpenPayload
in display_test.dart
.
matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); | ||
}, variant: const TargetPlatformVariant({TargetPlatform.iOS})); | ||
|
||
testWidgets('(iOS) uses associated account as initial account; if initial route', (tester) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand what a ";" means here.
// Set up a value for `PlatformDispatcher.defaultRouteName` to return, | ||
// for determining the initial route. | ||
final message = eg.streamMessage(); | ||
final payload = messageApnsPayload(message, account: eg.selfAccount); | ||
testBinding.notificationPigeonApi.setNotificationDataFromLaunch( | ||
NotificationDataFromLaunch(payload: payload)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this comment accurate? I don't see anything about PlatformDispatcher.defaultRouteName
here.
It looks like this test case is copied from an existing one in display_test.dart
. But that one says:
// Set up a value for `PlatformDispatcher.defaultRouteName` to return,
// for determining the initial route.
final account = eg.selfAccount;
final message = eg.streamMessage();
final data = messageFcmMessage(message, account: account);
final intentDataUrl = NotificationNavigationData(
/* … */).buildAndroidNotificationUrl();
addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue);
tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString();
so PlatformDispatcher.defaultRouteName
really is what it's about.
test/notifications/display_test.dart
Outdated
final account = eg.selfAccount; | ||
final message = eg.streamMessage(); | ||
final data = messageFcmMessage(message, account: account); | ||
final intentDataUrl = NotificationOpenPayload( | ||
final intentDataUrl = NotificationNavigationData( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Speaking of this test, it looks like it (and several of its neighbors) should also move to navigate_test.dart
/ open_test.dart
as part of the code-moving commit — the code they're testing is part of what's getting moved.
(In particular the way these tests exercise the code they're testing is via ZulipApp — and the relevant call sites in ZulipApp switch from NotificationDisplayManager methods to NotificationNavigationService methods.)
As a bonus, those moves may allow you to outright move some of the helpers from this test file, rather than copy them as the current version does.
lib/notifications/display.dart
Outdated
final route = routeForNotification(context: context, url: url); | ||
assert(url.scheme == 'zulip' && url.host == 'notification'); | ||
final payload = | ||
NotificationNavigationService.tryParseAndroidNotificationUrl(context, url); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This navigateForNotification
method seems like it should move too — it's logically closely related to the other methods like routeForNotification
which are moving.
It's also quite similar to the _navigateForNotification
method which the last commit introduces on the other class. It might be fine to have some code duplication between these two methods — but if we do that, it's definitely better if they're right next to each other, so that the parallelism is more visible.
1c03950
to
2c3c35d
Compare
…art` This makes it clear that these bindings are for Android only.
And fix a typo.
Also make use of a handy shorthand within `jq`.
7d58286
to
2e40fb0
Compare
And move the notification navigation data parsing utilities to the new class.
To make it clear that they are Android specific.
Update it to receive `NotificationOpenPayload` as an argument instead of the Android Notification URL. Also rename `navigateForNotification` to `navigateForAndroidNotificationUrl`, making it more explicit.
Introduces NotificationOpenPayload.parseIosApnsPayload which can parse the payload that Apple push notification service delivers to the app for displaying a notification. It retrieves the navigation data for the specific message notification.
Introduces a new Pigeon API file, and adds the corresponding bindings in Swift. Unlike the `pigeon/android_notifications.dart` API this doesn't use the ZulipPlugin hack, as that is only needed when we want the Pigeon functions to be available inside a background isolate (see doc in `zulip_plugin/pubspec.yaml`). Since the notification tap will trigger an app launch first (if not running already) anyway, we can be sure that these new functions won't be running on a Dart background isolate, thus not needing the ZulipPlugin hack.
2e40fb0
to
02506ba
Compare
Thanks for the review @gnprice! Pushed an update, PTAL. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, thanks for splitting these out as separate commits — that's very helpful for reading them. They all look good.
5292653 notif [nfc]: Rename NotificationOpenPayload methods
d130717 notif [nfc]: Refactor NotificationOpenService.routeForNotification
b2e0ed0 notif: Show a dialog if received malformed Android Notification URL
I've now finished reading the whole thing, and generally it all looks good; just a handful of comments below. A couple of them are for follow-ups we can pursue after launch.
There's one commit making a bunch of test changes which I'd like split up to make clearer — as is, I don't yet fully understand all the things it's doing, which means it's hard to be sure whether it's accidentally undermining some of the tests. (Which in turn would mean there could be a bug that the tests are no longer able to catch.)
if (!dropStartingRoutes) { | ||
check(pushedRoutes).isEmpty(); | ||
return; | ||
} | ||
await tester.pump(); | ||
takeStartingRoutes(account: account); | ||
check(pushedRoutes).isEmpty(); | ||
} | ||
|
||
Uri androidNotificationUrlForMessage(Account account, Message message) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This commit f12f911 does several different things:
notif test: Refactor tests for NotificationOpenService
---
test/notifications/open_test.dart | 103 +++++++++++++++++++++++------------------------------
1 file changed, 45 insertions(+), 58 deletions(-)
- make
init
not handle adding the account to the store, moving that to its callers - OTOH make
prepare
add the account to the store by default, which it previously never did, moving that from its callers - add an
account
option toprepare
, for using something other thaneg.selfAccount
- rename
early
todropStartingRoutes
and flip the direction of it - split out this
androidNotificationUrlForMessage
method fromopenNotification
- a couple of other things
I understand some of the changes it's making. But it's hard to track all of them, and as a reviewer to check that they all line up and identify any changes in behavior they might make, because there are so many things combined in one.
It's also hard to tell what the motivation is for many of them, partly because it seems like there are probably two or three different motivations for different changes.
So these would be helpful to split into several commits:
- Those first three points, about managing whether to involve an account and which one, would be at least one commit of their own. Probably can be further clarified by splitting into two or three commits.
- The
early
/dropStartingRoutes
point should be an easy small commit of its own. - The
androidNotificationUrlForMessage
split makes another. - Then at least one other commit for the remaining changes (like
setupNotificationDataForLaunch
); not sure how many because I'm not sure what all the remaining changes are.
}); | ||
|
||
group('parseIosApnsPayload', () { | ||
test('smoke one-one DM', () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, these tests are a good start.
Because of the nature of this function as parsing code (echoing #1379 (comment)), and because notifications are important, I'd like to have more thorough testing for it than only smoke tests.
But let's make that a follow-up issue we can pursue after launch. Even if there are edge cases this code doesn't do anything on, that won't be a regression from what's in main. (And if there are edge cases where it does something but it's the wrong thing, going to the wrong narrow is probably not much worse than doing nothing.)
switch (defaultTargetPlatform) { | ||
case TargetPlatform.android: | ||
final intentDataUrl = androidNotificationUrlForMessage(account, message); | ||
unawaited( | ||
WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); | ||
await tester.idle(); // let navigateForNotification find navigator | ||
|
||
case TargetPlatform.iOS: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, I like this mechanism for reusing the same test code for both platforms.
case TargetPlatform.iOS: | ||
_notifDataFromLaunch = await _notifPigeonApi.getNotificationDataFromLaunch(); | ||
_notifPigeonApi.notificationTapEventsStream() | ||
.listen(_navigateForNotification); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if the user opens the app from the home screen, and then immediately taps a notification, while the app is still just beginning to start up and so before it reaches this listen
call?
In that case the notification won't get passed at startup to application(_:didFinishLaunchingWithOptions:)
. Instead it'll go to userNotificationCenter(_:didReceive:withCompletionHandler:)
. Will it make it to the app's Dart code so that the right conversation gets opened, or will it get dropped because there isn't a listener yet?
Looking at the Swift code, I think it will get dropped:
override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<NotificationTapEvent>) {
eventSink = sink
}
func onNotificationTapEvent(payload: [AnyHashable : Any]) {
eventSink?.success(NotificationTapEvent(payload: payload))
}
I think the ideal behavior would be that instead it gets buffered, stored in the stream, and then when the app reaches this point it consumes any notification that's been buffered there.
If there isn't an obvious tweak to make that would provide that behavior, we can cheerfully leave it as a followup. I think this isn't a super common case.
[greg: cherry-picked from zulip#1379]
[greg: cherry-picked from #1379]
Fixes #1147.
2nd attempt, first attempt was #1261. This one uses pigeon to move most of the notification payload handling to dart side (and doesn't use/rely on
zulip://notification
URL).